﻿# -*- coding: UTF-8 -*-
# 导入所需的库
import logging
import requests
import asyncio
from alibabacloud_dysmsapi20170525.client import Client as Dysmsapi20170525Client
from alibabacloud_tea_openapi import models as open_api_models
from alibabacloud_dysmsapi20170525 import models as dysmsapi_20170525_models
from alibabacloud_tea_util import models as util_models
from datetime import datetime
from selenium import webdriver
from selenium.webdriver.common.by import By
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC


# 设置Chrome驱动程序的路径
# chrome_driver_path = r"C:\\软件\\chromedriver\\chromedriver.exe"  # Windows路径
chrome_driver_path = r"/data/chromedriver/chromedriver"  # Linux路径

# 创建Chrome选项对象
options = webdriver.ChromeOptions()
options.add_argument("--mute-audio")  # 静音音频
options.add_argument("--headless")  # 无头模式(无GUI界面)
options.add_argument("--disable-gpu")  # 禁用GPU加速
options.add_argument("--no-sandbox")  # 解决DevToolsActivePort文件不存在的报错
options.add_argument("--disable-dev-shm-usage")  # 解决浏览器崩溃问题
options.add_experimental_option("excludeSwitches", ["enable-logging"])  # 禁止打印日志
options.add_argument("--log-level=3")  # 设置日志级别为3(致命错误)
# options.headless = True  # 另一种设置无头模式的方式


# 设置日志
log_file_path = "/data/python/log/shumai.log"  # 指定日志文件路径
logging.basicConfig(
    filename=log_file_path,
    level=logging.INFO,  # 设置日志级别为INFO
    format="%(asctime)s - %(levelname)s - %(message)s",  # 设置日志格式
    datefmt="%Y-%m-%d %H:%M:%S",  # 设置日期时间格式
)


def nowTime():
    """
    获取当前时间,并将其格式化为字符串形式。

    Returns:
        str: 格式化后的当前时间字符串,格式为"YYYY-MM-DD HH:MM:SS"。
    """
    now = datetime.now()  # 获取当前时间
    # 将时间格式化为字符串
    formatted_time = now.strftime("%Y-%m-%d %H:%M:%S")
    return formatted_time


def create_client() -> Dysmsapi20170525Client:
    """
    创建阿里云短信服务客户端对象。

    Returns:
        Dysmsapi20170525Client: 阿里云短信服务客户端对象。
    """
    config = open_api_models.Config(
        access_key_id="LTAI4GHoGkVBr1CT5C57Y64j",
        access_key_secret="5apl1h6CDXS2IBdmpKIhaHXhgIAxJv",
    )
    config.endpoint = f"dysmsapi.aliyuncs.com"
    return Dysmsapi20170525Client(config)


async def Send_message(key, old_value, new_value, change):
    """
    异步发送短信,通知产品余量变化情况。

    Args:
        key (str): 产品名称。
        old_value (int): 旧的产品余量。
        new_value (int): 新的产品余量。
        change (int): 产品余量的变化量。

    该函数执行以下步骤:
    1. 调用create_client()函数创建阿里云短信服务客户端对象。
    2. 构造发送短信请求对象,包括接收手机号码、签名名称、模板代码和模板参数。
    3. 使用异步方式调用客户端的send_sms_with_options_async()方法发送短信。
    4. 如果发生异常,打印错误信息和诊断地址。
    """
    client = create_client()

    send_sms_request = dysmsapi_20170525_models.SendSmsRequest(
        phone_numbers="13421830635,18320811590",  # 接收短信的手机号码
        sign_name="爱问健康",  # 短信签名名称
        template_code="SMS_465312108",  # 短信模板代码
        template_param='{"product_name":"%s","old_num":"%s","new_num":"%s","num":"%s"}'
        % (key, old_value, new_value, change),  # 短信模板参数
    )

    runtime = util_models.RuntimeOptions()

    try:
        # 复制代码运行请自行打印 API 的返回值
        await client.send_sms_with_options_async(send_sms_request, runtime)
    except Exception as error:
        # 错误 message
        err_msg = error.message
        logging.error(f"发送信息出错：{err_msg}\n")
        # 诊断地址
        err_addr = error.data.get("Recommend")
        logging.error(f"诊断地址：{err_addr}\n")


# 从文件中读取保存的Token
def Get_saved_token():
    """
    从指定的文件路径读取之前保存的Token。

    该函数尝试读取位于"/data/python/log/token.txt"的文件,
    并返回文件中的Token字符串。如果文件不存在,则返回None。

    Returns:
        str或None: 如果文件存在且包含Token,则返回Token字符串;
                   如果文件不存在,则返回None。
    """
    try:
        # 以只读模式打开文件
        with open("/data/python/log/token.txt", "r") as file:
            # 读取文件内容并去除前后的空白字符
            token = file.read().strip()

        # 记录成功读取到的Token
        logging.info(f"成功读取保存的 Token：{token}")
        return token

    except FileNotFoundError:
        # 如果文件不存在,记录警告日志
        logging.warning("token.txt 文件未找到,可能还没有保存 Token。\n")
        return None


# 将Token保存到文件
def Save_token_to_file(token):
    """
    将给定的Token字符串保存到指定的文件路径。

    Args:
        token (str): 要保存的Token字符串。

    该函数尝试以写模式打开位于"/data/python/log/token.txt"的文件,
    并将传入的Token字符串写入该文件。如果写入操作成功,则记录成功日志;
    如果发生任何异常,则记录错误日志。

    注意:
        - 如果文件不存在,将自动创建该文件。
        - 如果文件已存在,则会被覆盖。
    """
    try:
        # 以写模式打开文件
        with open("/data/python/log/token.txt", "w") as file:
            # 将Token写入文件
            file.write(token)

        # 记录成功保存Token的日志
        logging.info("Token 已成功保存到文件。")

    except Exception as e:
        # 捕获任何异常,记录错误日志
        logging.error(f"保存 Token 到文件时出错：{e}\n")


# 检查Token是否有效
def Check_token_validity(token):
    """
    检查给定的Token是否有效。

    Args:
        token (str): 要检查的Token字符串。

    Returns:
        bool: 如果Token有效,返回True;否则返回False。

    该函数向指定的API端点发送GET请求,并在请求头中包含给定的Token。
    它检查响应数据中的"code"字段是否不等于"ERROR",以及"data"字段是否不为None。
    如果这两个条件都满足,则认为Token有效,返回True;否则认为Token无效,返回False。

    如果在发送请求或处理响应时发生任何异常,函数将记录错误日志并返回False。
    """
    url = "https://my.shumaiapi.com/api/merchant/business/product/myproduct?name=&pageNo=1&pageSize=999"

    # 添加伪装的User-Agent
    headers = {
        "Token": f"{token}",
        "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/58.0.3029.110 Safari/537.3",
    }

    try:
        # 发送GET请求
        response = requests.get(url, headers=headers)
        # 将响应体解析为JSON
        response_data = response.json()

        # 检查响应体中的code字段和data字段
        if (
            response_data.get("code") != "ERROR"
            and response_data.get("data") is not None
        ):
            logging.info("Token 有效。")
            return True
        else:
            logging.info("Token 无效。")
            return False

    except Exception as e:
        # 记录任何异常并返回False
        logging.error(f"验证 Token 有效性时发生错误：{e}\n")
        return False


# 登录并获取Token
def Login():
    """
    使用Selenium自动化浏览器,模拟登录网站并获取Token。

    该函数会启动一个Chrome浏览器实例,并执行以下操作:
    1. 打开登录页面
    2. 输入用户名和密码
    3. 勾选同意协议复选框
    4. 点击登录按钮
    5. 等待页面加载完成
    6. 展开左侧菜单
    7. 点击左侧菜单中的第一个子菜单项
    8. 从浏览器localStorage中获取Token

    如果获取到Token,则将其保存到文件中;如果获取失败,则记录错误日志。
    最后,无论是否获取到Token,该函数都会退出浏览器实例。

    如果在执行过程中发生任何异常,将记录错误日志。
    """
    # 使用指定的驱动程序路径和选项创建WebDriver实例
    driver = webdriver.Chrome(executable_path=chrome_driver_path, options=options)
    wait = WebDriverWait(driver, 10)  # 设置最长等待时间为10秒
    driver.get("https://my.shumaiapi.com/#/login")
    driver.set_window_size(1920, 1080)  # 设置浏览器窗口大小

    try:
        # 输入用户名和密码
        driver.find_element_by_css_selector(
            'input[placeholder="手机号/邮箱"]'
        ).send_keys("18148534515")
        driver.find_element_by_css_selector('input[placeholder="密码"]').send_keys(
            "Aiwenyilian123"
        )

        # 勾选同意协议
        driver.find_element_by_css_selector('span[class="el-checkbox__inner"]').click()

        # 点击登录按钮
        driver.find_element_by_css_selector(
            'button[class="el-button el-button--primary login-btn"]'
        ).click()

        # 等待余额信息出现
        wait.until(
            EC.visibility_of_element_located(
                (By.CSS_SELECTOR, 'p[class="message-balance-lable"]')
            )
        )

        # 点击左侧菜单展开
        driver.find_element_by_css_selector('div[class="el-submenu__title"]').click()

        # 等待子菜单出现
        wait.until(
            EC.visibility_of_element_located(
                (
                    By.CSS_SELECTOR,
                    "#app > div > div.layout-sidebar.sidebar > div > div.menu-content > ul > li.el-submenu.is-opened > ul > li > ul > li:nth-child(1)",
                )
            )
        )

        # 点击子菜单第一项
        driver.execute_script(
            "arguments[0].click();",
            driver.find_element(
                By.CSS_SELECTOR,
                "#app > div > div.layout-sidebar.sidebar > div > div.menu-content > ul > li.el-submenu.is-opened > ul > li > ul > li:nth-child(1)",
            ),
        )

        # 获取localStorage中的token
        localStorage_token = driver.execute_script(
            "return window.localStorage.getItem('token');"
        )

        if localStorage_token:
            logging.info("登录成功！")
            logging.info(f"Token 获取成功：{localStorage_token}")
            Save_token_to_file(localStorage_token)  # 保存Token到文件
        else:
            logging.error("Token 为空，未能成功获取。")

    except Exception as e:
        logging.error(f"登录失败：{e}\n")

    finally:
        driver.quit()  # 退出浏览器


# 获取产品余量数据
def Getdata(token):
    """
    使用给定的Token向API发送请求,获取指定产品的余量数据。

    Args:
        token (str): 用于身份验证的Token字符串。

    Returns:
        dict: 一个字典,其中键为产品名称,值为对应产品的余量。
              如果请求失败或发生异常,则返回空字典。

    该函数执行以下步骤:
    1. 构造API请求的URL。
    2. 设置请求头,包括Token和伪装的User-Agent。
    3. 发送GET请求到指定的URL。
    4. 如果请求成功(状态码为200):
        a. 从响应数据中获取"data"字段,其中包含产品列表。
        b. 初始化一个空字典product_details,用于存储产品余量数据。
        c. 遍历产品列表:
            i. 检查产品名称是否在指定列表中(包括"银行卡三要素验证"、"手持身份证人脸比对"、"身份证OCR识别"和"身份证实名认证")。
            ii. 如果在列表中,则从该产品中提取名称和余量信息,并将其存储在product_details字典中。
            iii. 记录每个提取的产品的余量信息。
        d. 返回包含产品余量数据的product_details字典。
    5. 如果请求失败(状态码不是200),则记录失败日志,并返回空字典。
    6. 如果在发送请求或处理响应时发生任何异常,将记录错误日志并返回空字典。

    注意:
        - 该函数使用requests库发送HTTP请求。
        - 响应数据被假设为JSON格式,使用json()方法进行解析。
        - 只提取了四种指定产品的余量信息,其他产品将被忽略。
        - 如果"data"字段不存在或为None,则被视为空列表。
    """
    url = "https://my.shumaiapi.com/api/merchant/business/product/myproduct?name=&pageNo=1&pageSize=999"

    # 添加伪装的User-Agent
    headers = {
        "Token": f"{token}",
        "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/58.0.3029.110 Safari/537.3",
    }

    try:
        # 发送GET请求
        response = requests.get(url, headers=headers)

        if response.status_code == 200:
            # 将响应体解析为JSON
            response_data = response.json()
            # 获取响应数据中的"data"字段,如果不存在则返回空列表
            products = response_data.get("data", [])
            product_details = {}

            # 遍历产品列表
            for product in products:
                # 检查产品名称是否在指定列表中
                if product["productName"] in [
                    "银行卡三要素验证",
                    "手持身份证人脸比对",
                    "身份证OCR识别",
                    "身份证实名认证",
                ]:
                    # 如果在列表中,则从该产品中提取名称和余量信息
                    product_name = product["productName"]
                    left_num = product["leftNum"]

                    # 记录提取的产品余量信息
                    logging.info(f"[{product_name}]的余量为: {left_num}")

                    # 将产品名称和余量存储在字典中
                    product_details[product_name] = left_num

            # 返回包含产品余量数据的字典
            return product_details
        else:
            logging.info(f"请求失败,状态码：{response.status_code}")
            return {}

    except Exception as e:
        logging.error(f"获取数据失败：{e}\n")
        return {}


# 比较数据变化
async def Compare_data(old_data, new_data):
    """
    这是一个异步函数,用于比较两个数据集合(字典)中相同键对应的值的差异,
    如果差异超过某个阈值(这里设置为50),则记录日志并发送告警信息。

    参数:
        old_data (dict): 旧的数据集合
        new_data (dict): 新的数据集合
    """

    nowtime = nowTime()  # 获取当前时间,用于记录日志

    tasks = []  # 用于存储发送告警信息的任务

    if not old_data or not new_data:
        # 如果有一个数据为空,记录错误日志并直接返回
        logging.error("其中一个数据为空,无法比较。")
        return

    for key in old_data:
        # 遍历旧数据集合中的每个键
        old_value = old_data[key]
        new_value = new_data.get(
            key, old_value
        )  # 为避免键不存在的错误，使用 get 方法，并提供默认值

        change = old_value - new_value  # 计算两个值的差异

        if change > 50:  # 注意这里的条件应根据实际情况调整
            logging.info(
                f"{nowtime} '{key}' 使用异常: 1小时前为 {old_value}, 当前为 {new_value}, 使用 {change}, 超 50, 请关注!"
            )
            # 创建一个协程来发送告警信息
            send_message_task = asyncio.ensure_future(
                Send_message(key, old_value, new_value, change)
            )

            # 等待5秒后继续下一个任务
            await asyncio.sleep(10)

            # 将任务添加到列表中
            tasks.append(send_message_task)

    # 等待所有任务完成
    await asyncio.gather(*tasks)
    logging.info("所有告警信息已发送！")


"""
            task = asyncio.ensure_future(
                Send_message(key, old_value, new_value, change)
            )
            tasks.append(task)

    if tasks:
        await asyncio.gather(*tasks)
        logging.info("所有告警信息已发送！")
"""


# 获取并返回产品余量数据
def Return_data():
    """
    获取并返回产品余量数据。

    该函数执行以下步骤:
    1. 调用Get_saved_token()函数获取保存的Token。
    2. 如果Token不为空并且通过Check_token_validity()函数验证有效:
        a. 记录日志信息"现有 Token 有效,跳过登录步骤"。
        b. 调用Getdata()函数并传入Token,获取产品余量数据。
        c. 返回产品余量数据。
    3. 如果Token为空或无效:
        a. 记录日志信息"现有 Token 无效,需要登录"。
        b. 调用Login()函数进行登录并获取新的Token。
        c. 重新调用Get_saved_token()函数获取新的Token。
        d. 如果新的Token不为空,则调用Getdata()函数并传入新的Token,获取产品余量数据。
        e. 返回产品余量数据。

    Returns:
        dict或None: 一个字典,其中键为产品名称,值为对应产品的余量;如果获取失败,则返回None。
    """
    token = Get_saved_token()  # 获取保存的Token
    if token and Check_token_validity(token):  # 如果Token有效
        logging.info("现有 Token 有效,跳过登录步骤。")
        product_info = Getdata(token)  # 传入token获取产品余量数据
        return product_info
    else:
        logging.info("现有 Token 无效,需要登录。")
        Login()  # 登录获取新的Token
        # 重新获取token
        token = Get_saved_token()
        if token:
            product_info = Getdata(token)  # 使用新获取的token获取产品余量数据
            return product_info


# 主函数
async def Run_spiders_and_compare():
    """
    这是程序的主函数,用于运行爬虫获取数据,然后比较一个小时前的数据与当前数据的差异。

    首先,它获取当前的数据并保存为old_data。
    然后,它等待一个小时(3600秒)。
    接着,它再次获取当前的数据并保存为new_data。
    最后,它调用Compare_data函数,将old_data和new_data作为参数传入,以比较两个数据集合中相同键对应的值的差异,如果差异超过阈值,就发送告警信息。
    """

    old_data = Return_data()  # 获取当前数据,保存为old_data
    await asyncio.sleep(3600)  # 使用异步版本的sleep,等待一个小时(3600秒)
    new_data = Return_data()  # 获取当前数据,保存为new_data
    await Compare_data(old_data, new_data)  # 比较两个数据集合,发送告警信息
    logging.info("程序运行结束。\n")  # 记录程序结束日志


# 程序入口
if __name__ == "__main__":
    """
    这是程序的入口,用于启动事件循环并运行主函数Run_spiders_and_compare。
    """
    # 获取当前事件循环
    loop = asyncio.get_event_loop()
    try:
        # 运行主函数Run_spiders_and_compare,并等待它完成
        loop.run_until_complete(Run_spiders_and_compare())
    finally:
        # 无论主函数是否成功完成,最后都要关闭事件循环
        loop.close()
